Skip to content

feat: support $dynamicRef / $dynamicAnchor (JSON Schema 2020-12) #337

Merged
aeschli merged 8 commits into
microsoft:mainfrom
keegan-caruso:users/keegancaruso/2020-12-schema
Jul 12, 2026
Merged

feat: support $dynamicRef / $dynamicAnchor (JSON Schema 2020-12) #337
aeschli merged 8 commits into
microsoft:mainfrom
keegan-caruso:users/keegancaruso/2020-12-schema

Conversation

@keegan-caruso

@keegan-caruso keegan-caruso commented Jul 7, 2026

Copy link
Copy Markdown
Member

The simple case: A $dynamicAnchor is registered as a normal anchor, the same way $anchor is, so references can find it. A $dynamicRef resolves to wherever it points lexically. For many schemas that's all you need, and it behaves like a plain $ref. This is the first commit.

The second part is dynamic resolution. To resolve dynamically, you need to know which anchors belong to which schema resource. The problem is that $ref merging flattens those resource boundaries before we get a chance to look. So, we run a pre-pass (collectDynamicAnchors) over the original tree first. It records, for each resource, which anchors it has (the dynamic ones and all of them), and which resource each $dynamicRef was written in. A $dynamicRef resolves its first target inside its own resource, so an anchor with the same name in a sibling resource can't be picked up by mistake.

At validation time the rule is this: if the first target is a $dynamicAnchor with the right name, we look through the current dynamic scope from the outside in and use the outermost match. If it isn't, the reference stays where it is and acts like a plain $ref. In both cases the $dynamicRef is applied together with any sibling keywords on the same node, not in place of them.

That pre-pass has a limit worth calling out: it runs over the document being validated. Within a single document the dynamic scope is solid, and that's most of what you'll hit. The weak spot is bookending that has to reach into a separate document. A $dynamicAnchor that overrides a base default can live in another document reached by $ref, and there it never joined the scope, so the override was lost. The reference still found its starting point the normal way — it just didn't extend the scope across the document boundary the way the root does. The fix is to collect a referenced document's anchor maps too, once, while the external link is resolved (again, before merging flattens the boundaries), and fold them in with the same outermost-wins rule. Each resource's draft is read from its own $schema, so pulling in a pre-2020-12 document doesn't hand it $dynamicAnchor semantics.

Updating the vendored test suite to current main turned up a handful of related reference-scope bugs, in 2019-09 as much as 2020-12. None are in $dynamicRef itself — they're places where $ref or $recursiveRef merging was too eager or quietly dropped something:

• A $recursiveRef stopped after validating its target, so a sibling unevaluatedItems or unevaluatedProperties on the same node never got a turn. It now falls through, and they see the annotations the reference produced.
• When the referenced and referencing schemas, both constrain array items, a flatten-merge could only keep one of them. Those cases isolate into an allOf so both apply — and the anchor and unevaluated* keywords stay on the wrapper, so bookending targets stay findable and unevaluated* still sees every applicator's results.
• A boolean subschema — true or false — is a real schema, not a missing section, when a $ref points at one.
• A referenced document's own embedded $id resources are registered as targets, so a nested $ref by that id resolves to them instead of being fetched again as a fresh document.
• Only the dynamic anchor map is folded across resources; the per-resource map stays put, so a sibling's identically named anchor can't leak into a $dynamicRef's lexical lookup.

Two smaller points:

• The internal data (the per-resource anchor maps, and each ref's first target and scope) is stored in non-enumerable fields so it doesn't show up in serialized schemas. It's kept in two records, DynamicRefInfo on the reference and AnchorMaps on the resource root, rather than several separate properties.
• In drafts older than 2020-12, $dynamicRef is treated as an unknown keyword and ignored, so nothing changes for existing schemas.

Testing

New project-owned tests cover the scenarios above — the $dynamicRef/$dynamicAnchor edge cases, the cross-document overrides, and the reference-scope fixes — so none of these leans entirely on the vendored suite. The vendored JSON-Schema-Test-Suite is bumped to current main (92acb61) and runs across draft4/6/7/2019-09/2020-12 with nothing skipped: 5326 tests, all passing.

Code coverage

Line: 97.5%
Branch: 93%
Func: 95%

Keegan Caruso added 3 commits July 6, 2026 14:36
…r (2020-12)

Implement the first half of JSON Schema 2020-12 $dynamicRef/$dynamicAnchor
support:

- Register $dynamicAnchor names as ordinary anchors so a reference can resolve
  to them, exactly like $anchor.
- Resolve $dynamicRef to its initial (lexically-static) target and validate the
  instance against it, so $dynamicRef behaves like a plain $ref to the location
  it initially points at. The target is kept as hidden, non-enumerable metadata
  ($dynamicRefTarget) so sibling keywords on the reference are preserved.
- Remove the previous 'unsupported feature' warning for $dynamicRef/
  $dynamicAnchor and update the corresponding tests.

True dynamic-scope (bookended) resolution is added in a follow-up commit; the 7
official test-suite cases that require it are temporarily skipped.
Complete JSON Schema 2020-12 $dynamicRef support with true dynamic-scope
("bookended") resolution:

- Add a pre-pass (collectDynamicAnchors) that, from the original schema tree
  before $ref merging flattens resource boundaries, builds per-resource anchor
  maps ($anchorMaps: dynamic-only and all-anchor) and records each $dynamicRef's
  lexical resource (in $dynamicRefInfo.scope).
- Resolve an internal $dynamicRef's initial target within its own lexical
  resource so a sibling resource's identically-named anchor cannot leak in.
- At validation time, apply the bookending rule: when the initial target is a
  $dynamicAnchor of the referenced name, resolve to the outermost matching
  $dynamicAnchor currently in the dynamic scope; otherwise behave like a plain
  $ref.
- Apply $dynamicRef alongside any sibling keywords on the same node, rather than
  treating it as a redirect that replaces them.
- Propagate the hidden $dynamicRef metadata through schema merging so a
  $dynamicRef living directly on a $ref'd resource still resolves.
- Ignore $dynamicRef as an unknown keyword in drafts before 2020-12.
- Store the internal, non-enumerable metadata in two consolidated records
  (DynamicRefInfo and AnchorMaps) rather than five separate hidden fields.

All 32 official dynamicRef test-suite cases now pass; the temporary skips added
in the previous commit are removed.
Add project-owned tests for the gaps found in the $dynamicRef coverage
review, so the behavior no longer relies solely on the vendored official
suite:

- a sub-schema shared by reference is anchor-collected once (shared-node
  guard) and raises no spurious duplicate-anchor error
- a duplicate $dynamicAnchor within one resource is reported as a schema
  resolution error
- genuine dynamic-scope resolution: an outer $dynamicAnchor overrides a
  base resource's default (extendible pattern)
- a $dynamicRef to a plain $anchor (not bookended) behaves like a plain $ref
- a $dynamicRef with a JSON-pointer fragment behaves like a plain $ref
- a $dynamicRef resolved through an external schema
- $dynamicRef is ignored as an unknown keyword in draft 2019-09
- completion and hover resolve through a $dynamicRef node
@keegan-caruso

Copy link
Copy Markdown
Member Author

FYI @jdesrosiers and @aeschli

@baywet

baywet commented Jul 7, 2026

Copy link
Copy Markdown
Member

Cc @handrews and @karenetheridge

Keegan Caruso and others added 2 commits July 8, 2026 09:18
…-12)

The dynamic-scope pre-pass (collectDynamicAnchors) ran only over the root
document's resources, so a $dynamicAnchor that overrides a base default was
honored only when it lived in the document being validated. When the
extending resource lived in a *separate* document reached via $ref, its
$dynamicAnchor never joined the dynamic scope and the override was lost.

- Collect each referenced document's own anchor maps (once) during external
  link resolution, before $ref merging flattens resource boundaries, so its
  $dynamicAnchor declarations can participate in the dynamic scope and its
  $dynamicRef nodes get their lexical resource recorded.
- Fold a merged section's anchor maps into the target during merge, with
  outermost-resource-wins precedence, so a $dynamicAnchor defined in an
  external (or $ref'd sub-) resource can override a base default while the
  base's own entries keep priority.
- Derive the effective draft from each resource's own $schema so a referenced
  pre-2020-12 document is not given $dynamicAnchor semantics.

Tests cover the cross-document override for both an internal-fragment
($dynamicRef "#name") and a non-fragment URI ($dynamicRef "other#name")
starting point. Each case uses a fresh service and a fresh schema object:
in-place $ref resolution mutates the schema it is given, so reusing one
object would let a prior validation leak anchor state and mask the gap.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Bump the vendored JSON-Schema-Test-Suite from 69acf52 to 92acb61 and fix
the spec-compliance gaps it surfaced. The full suite now passes with no
skipped assertions.

- $recursiveRef no longer early-returns, so sibling unevaluatedItems /
  unevaluatedProperties still evaluate against the node and accumulate
  their processed-property annotations.
- Resolve $defs/definitions before if/then/else/items during traversal so
  a referenced resource finishes assembling before a referrer merges it.
  Fixes a $dynamicRef chain reached through then: { $ref: ... } capturing
  a half-resolved snapshot.
- Isolate into an allOf (instead of flatten-merging) when both schemas
  constrain array items, or when a referencing schema's unevaluatedItems
  would otherwise capture a referenced schema's positional items and its
  internal self-references.
- Keep $recursiveAnchor/$dynamicAnchor and unevaluated* on the allOf
  wrapper so bookending targets stay discoverable and unevaluated* observes
  the aggregated annotations of every in-place applicator.
- Treat a boolean subschema (true/false) as a real schema when following a
  $ref, not as a missing section.
- Register a referenced document's embedded $id resources as resolvable
  handles, and join a fragment-referenced resource's $dynamicAnchor names
  to the referencing dynamic scope.
- Fold only the dynamic anchor map across resources; the local map stays
  per-resource so a sibling's identically-named anchor cannot leak into an
  internal $dynamicRef's lexical resolution.
aeschli
aeschli previously approved these changes Jul 11, 2026
@aeschli
aeschli enabled auto-merge (squash) July 11, 2026 09:19
lszomoru
lszomoru previously approved these changes Jul 11, 2026
TylerLeonhardt
TylerLeonhardt previously approved these changes Jul 11, 2026
vijayupadya
vijayupadya previously approved these changes Jul 11, 2026
@aeschli aeschli closed this Jul 11, 2026
auto-merge was automatically disabled July 11, 2026 17:06

Pull request was closed

@aeschli aeschli reopened this Jul 11, 2026
@aeschli
aeschli dismissed stale reviews from vijayupadya and TylerLeonhardt via c750a98 July 12, 2026 15:10
@aeschli
aeschli enabled auto-merge (squash) July 12, 2026 15:10
@aeschli
aeschli merged commit 257468d into microsoft:main Jul 12, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

7 participants